package com.chauhai.android.batsg.mymap;

import java.util.ArrayList;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.util.FloatMath;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import android.widget.RelativeLayout;

import com.chauhai.android.batsg.util.ImageUtil;

/**
 * Map view class.
 * <p>
 * A map view contains the map image and the locations on it.
 * <p>
 * The map view has the following function:
 * <ul>
 * <li>Zoom in, zoom out.</li>
 * <li>Scrolling.</li>
 * <li>Place location.</li>
 * </ul>
 * <p>
 * The user action drag and pinch zoom are enable by default.
 * They can be set by the attributes <code>allow_drag</code>
 * and <code>allow_zoom</code> in the layout XML.
 */
public class MapView extends RelativeLayout implements OnTouchListener {

  private static final String TAG = "MapView";

  /**
   * Minimum distance between two fingers to be accepted.
   */
  private static final float FINGERS_MIN_DISTANCE = 10f;

  // Action when user touches on the MapView.
  private static final int ACTION_NONE = 0;
  private static final int ACTION_DRAG = 1;
  private static final int ACTION_ZOOM = 2;

  /**
   * ImageView that holds the map image.
   */
  private ImageView imageView;

  /**
   * Locations to be put on the map.
   */
  private ArrayList<MapLocation> locations = new ArrayList<MapLocation>();

  /**
   * MapView width and height.
   */
  private Position mapSize = new Position(1, 1);

  /**
   * The size of the image set to the map.
   */
  private Position mapImageSize;

  /**
   * Zoom ratio so that the map image fits to the
   * MapView size.
   */
  private float zoomFitRatio = 1;

  /**
   * The matrix to move and zoom the image.
   */
  protected MapImageMatrix matrix;

  /**
   * Matrix status when user starts dragging.
   */
  private Matrix dragOriginMatrix = new Matrix();

  /**
   * zoomRatio when user starts pinch zooming.
   */
  private float pinchZoomOriginalRatio;

  /**
   * Action when user touches on the MapView.
   */
  private int action = ACTION_NONE;

  // Remember some things for zooming
  /**
   * The first finger when user pinch zooms.
   */
  private PointF dragOriginalFinger = new PointF();

  /**
   * Pinch zoom pivot.
   */
  private PointF pinchZoomCenter = new PointF();

  /**
   * The distance between fingers when user start pinch zooming.
   */
  private float pinchZoomStartDistance = 1f;

  /**
   * Process user action drag or not.
   */
  private boolean allowDrag = true;

  /**
   * Process user action pinch zoom or not.
   */
  private boolean allowZoom = true;

  /**
   * Mark if this MapView has been dragged or zoomed by user.
   * <p>
   * This is used to make the MapView zoom the image automatically
   * to fit to the View, until the user zooms it.
   */
  private boolean actionedByUser = false;

  /**
   * Construct the object from an XML file. Valid Attributes:
   *
   * @see android.view.View#View(android.content.Context,
   *    android.util.AttributeSet)
   */
  public MapView(Context context, AttributeSet attrs) {
    super(context, attrs);
    initObject();

    // Get the data from the XML AttributeSet.
    TypedArray a = context.obtainStyledAttributes(attrs,
        R.styleable.MapView);
    // Get data.
    allowDrag = a.getBoolean(
        R.styleable.MapView_allow_drag, allowDrag);
    allowZoom = a.getBoolean(
        R.styleable.MapView_allow_zoom, allowZoom);
    // Get map image.
    int mapImageResId = a.getResourceId(R.styleable.MapView_map_image, -1);
    if (mapImageResId >= 0) {
      setMapImage(
          ImageUtil.loadBitmapFromResource(getResources(), mapImageResId));
    }
    a.recycle();
  }

  /**
   * Add <code>child</code> to <code>locations</code>
   * if it is a <code>MapLocation</code>.
   */
  @Override
  public void addView(View child, int index, ViewGroup.LayoutParams params) {
    if (child instanceof MapLocation) {
      locations.add((MapLocation) child);
    }
    super.addView(child, index, params);
  }

  /**
   * Remove <code>view</code> from <code>locations</code>
   * if it is a <code>MapLocation</code>.
   */
  @Override
  public void removeView(View view) {
    if (view instanceof MapLocation) {
      locations.remove(view);
    }
    super.removeView(view);
  }

  /**
   * Initiate the BaseMap object after creating it. This is called in the
   * constructor.
   */
  protected void initObject() {
    // Set this for onDraw to be called.
    this.setBackgroundColor(Color.TRANSPARENT);

    addImageView();

    reset();

    // Set OnTouchListener for imageView.
    imageView.setOnTouchListener(this);
  }

  /**
   * Create and add ImageView object to this MapView.
   */
  private void addImageView() {
    imageView = new ImageView(getContext());
    imageView.setScaleType(ScaleType.MATRIX);
    addView(imageView, LayoutParams.FILL_PARENT,
        LayoutParams.FILL_PARENT);
  }

  /**
   * Set an image as the map.
   *
   * @param mapImage
   */
  public void setMapImage(Bitmap image) {
    imageView.setImageBitmap(image);
    // Remember the map image size.
    mapImageSize = new Position(image.getWidth(), image.getHeight());
    Log.d(TAG, "Map image size: " + mapImageSize);
  }

  /**
   * Check if the map image is set.
   * @return
   */
  private boolean hasMapImage() {
    return mapImageSize != null;
  }

  /**
   * Add locations on the map.
   *
   * @param locations
   */
  public void addLocations(MapLocation[] locations) {
    for (MapLocation location : locations) {
      addView(location);
    }
  }

  /**
   * Remove all locations on this MapView.
   */
  public void removeAllLocations() {
    for (MapLocation location : this.locations) {
      removeView(location);
    }
  }

  /**
   * Add a location on the MapView.
   * @param location
   */
  public void addLocation(MapLocation location) {
    addView(location);
  }

  /**
   * Remove a location from the MapView.
   * @param location
   */
  public void removeLocation(MapLocation location) {
    removeView(location);
  }

  /**
   * Call this before re-set the map image or locations.
   */
  public void reset() {
    matrix = new MapImageMatrix();
    actionedByUser = false;
  }

  @Override
  protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    mapSize.set(w, h); // Remember the map size.
    calculateZoomFitRatio();
    super.onSizeChanged(w, h, oldw, oldh);
  }

  /**
   * Calculate the zoom fit ratio.
   * <p>
   * This is calculated based on the size of the map image
   * and the size of MapView, to find the ratio so that the
   * map image fits to the MapView.
   * <p>
   * If map image has not been set, then zoomMinRatio is not set.
   */
  private void calculateZoomFitRatio() {
    if (hasMapImage()) {
      // Get the minimum zoom ratio.
      zoomFitRatio = ImageUtil.sizeFitRatio(
          mapImageSize.x, mapImageSize.y, mapSize.x, mapSize.y);
    }
  }

  /**
   * Zoom the map image to fit to the MapView.
   * <p>
   * This will fit the map image to the MapView.
   */
  private void zoomToFit() {
    matrix.setScale(zoomFitRatio);
    zoom(zoomFitRatio, 0, 0, false);
  }

  /**
   * Zoom the map.
   *
   * @param ratio the zooming ratio.
   */
  public void zoom(float ratio) {
    zoom(ratio, getWidth() / 2, getHeight() / 2);
  }

  /**
   * Zoom the map with pivot point (px, py).
   * <p>
   * This zoom is marked to be done by user.
   * @param ratio
   * @param px
   * @param py
   */
  protected void zoom(float ratio, float px, float py) {
    zoom(ratio, px, py, true);
  }

  /**
   * Zoom the map with pivot point (px, py).
   * @param ratio
   * @param px
   * @param py
   * @param zoomedByUser set to true if this is performed by user,
   *     false otherwise.
   */
  protected void zoom(float ratio, float px, float py, boolean zoomedByUser) {
    this.actionedByUser = zoomedByUser;
    if (ratio > 0) {
      matrix.zoom(ratio, px, py);
      actionOnMap();
    }
  }

  /**
   * Render the map based on the matrix.
   * <p>
   * This will apply the matrix to the ImageView,
   * and re-locate the locations.
   */
  private void actionOnMap() {
    matrix.adjustMatrix(mapImageSize, getWidth(), getHeight());

    imageView.setImageMatrix(matrix.matrix);

    // Resize and re-locate the locations
    Position leftTop = matrix.getLeftTop();
    float zoomRatio = matrix.getZoomRatio();
    for (MapLocation location : locations) {
      location.zoom(zoomRatio, leftTop);
    }
  }

  @Override
  protected void onDraw(Canvas canvas) {
    if (!actionedByUser && hasMapImage()) {
      zoomToFit();
    }
    super.onDraw(canvas);
  }

  /**
   * Get zooming ratio.
   *
   * @return
   */
  public float getZoomRatio() {
    return matrix.getZoomRatio();
  }

  /**
   * Get the zoom ratio that fit the image to the map.
   *
   * @return
   */
  public float getZoomFitRatio() {
    return zoomFitRatio;
  }

  @Override
  public boolean onTouch(View v, MotionEvent event) {
    // Set to actionedByUser, so that the View will never
    // zoom automatically again.
    actionedByUser = true;

    switch (event.getAction() & MotionEvent.ACTION_MASK) {
    case MotionEvent.ACTION_DOWN: // User touches by one finger.
      if (allowDrag) {
        dragOriginMatrix.set(matrix.matrix); // Save the current matrix.
        // Remember the position of the first finger.
        dragOriginalFinger.set(event.getX(), event.getY());
        action = ACTION_DRAG; // Set touch mode to drag.
      }
      break;
    case MotionEvent.ACTION_POINTER_DOWN: // User makes a multi touch.
      if (allowZoom) {
        // Remember the distance between the two fingers,
        // used to calculate the scale.
        pinchZoomStartDistance = fingersDistance(event);
        if (pinchZoomStartDistance > FINGERS_MIN_DISTANCE) {
          pinchZoomOriginalRatio = getZoomRatio(); // Save zoomRatio.
          // Remember the center of pinch zoom.
          findFingersCenter(pinchZoomCenter, event);
          action = ACTION_ZOOM; // Set touch mode to zoom.
        }
        break;
      }
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_POINTER_UP:
      // Dismiss the touch event.
      action = ACTION_NONE;
      break;
    case MotionEvent.ACTION_MOVE:
      if (action == ACTION_DRAG) {
        matrix.matrix.set(dragOriginMatrix);
        matrix.matrix.postTranslate(event.getX() - dragOriginalFinger.x,
            event.getY() - dragOriginalFinger.y);
        actionOnMap();
      } else if (action == ACTION_ZOOM) {
        float fingerDist = fingersDistance(event);
        if (fingerDist > FINGERS_MIN_DISTANCE) {
          // Scale compared with saved zoom ratio.
          float scale = fingerDist / pinchZoomStartDistance;
          // Zoom
          zoom(scale * pinchZoomOriginalRatio,
              pinchZoomCenter.x, pinchZoomCenter.y);
        }
      }
      break;
    }
    return true; // indicate event was handled
  }

  /**
   * Determine the distance between the first two fingers
   * @param event multi touch event.
   * @return
   */
  private float fingersDistance(MotionEvent event) {
     float x = event.getX(0) - event.getX(1);
     float y = event.getY(0) - event.getY(1);
     return FloatMath.sqrt(x * x + y * y);
  }

  /**
   * Calculate the mid point of the first two fingers.
   * @param point The point that hold the result.
   * @param event multi touch event.
   */
  private void findFingersCenter(PointF point, MotionEvent event) {
     float x = event.getX(0) + event.getX(1);
     float y = event.getY(0) + event.getY(1);
     point.set(x / 2, y / 2);
  }
}